Skip to content

fix(reports): make the generated CSV actually downloadable - #11

Merged
theprogrammersingh merged 1 commit into
mainfrom
fix/report-csv-download
Sep 4, 2026
Merged

fix(reports): make the generated CSV actually downloadable#11
theprogrammersingh merged 1 commit into
mainfrom
fix/report-csv-download

Conversation

@theprogrammersingh

Copy link
Copy Markdown
Owner

The bug

generate_report finished and handed the user a dead link.

reports.service.ts set job.url = /api/reports/<id>/download, the tool returned it, and the model wrote it into chat as markdown. Nothing about that could work:

  • the Copilot renders assistant text as plain interpolation, so it was never an anchor;
  • the route needs the session bearer header, and the token lives in an in-memory signal with no cookie anywhere — so a browser navigation returns 401;
  • the route sent no Content-Disposition, so even authenticated the CSV would render inline instead of saving.

Verified against the running backend: 200 with the bearer, 401 without.

The fix

Replace the link with a real control, and stop handing the model a URL it cannot make work.

  • generate_report returns { jobId, rows, filename, preview, previewTruncated } — never the URL. The preview is bounded (header + 20 rows) and says when it is truncated.
  • Download button on the completed tool call card: fetches through ApiClient.download() with the token, saves via a blob (core/download/save-file.ts, core/reports/report-download.ts).
  • download_report — a new WebMCP tool, so this also works for a client that can only call tools. execute() runs inside the page in the authenticated session, so the page performs the fetch and the browser save on the agent's behalf. Requires the jobId from generate_report; annotated readOnlyHint: false (a file lands on the user's machine) with no confirmation (asking to download is the confirmation).
  • Backend: Content-Disposition: attachment with a range-named file, actuo-expenses-<from>_<to>.csv.

ReportDownload is split deliberately: save() throws and has no in-flight guard (the tool — a tool that reports a save that never happened lies to the model), download() keeps the guard and the per-job error signal (the button).

Tests

841 frontend + 161 backend unit + 37 e2e, all green. New coverage for the controller (which had none), saveBlob, ReportDownload, and the card's download affordance. Three existing exact-list guards caught the new tool and were updated.

Not verified here

The Copilot chat path needs a Gemini key in the browser (BYOK), so the model actually choosing to call download_report is worth one manual pass. The getTools() check for a third-party client also needs a signed-in session.

generate_report returned `/api/reports/<id>/download`, which the model
wrote into chat as a link. That route needs the session bearer header and
a browser navigation carries none, so every click 401'd.

- generate_report returns a job id and a bounded preview, never the URL
- the tool call card gets a Download button that fetches with the token
  and saves the blob
- new download_report tool for agents with no UI: execute() runs in the
  page's session, so the page downloads on their behalf
- the download route sends Content-Disposition
@theprogrammersingh
theprogrammersingh merged commit 84d5867 into main Sep 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant